本系列文已改編成書「Arduino 自造趣:結合 JavaScript x Vue x Phaser 輕鬆打造個人遊戲機」,本書改用 Vue3 與 TypeScript 全面重構且加上更詳細的說明,
在此感謝 iT 邦幫忙、博碩文化與編輯小 p 的協助,歡迎大家前往購書,鱈魚在此感謝大家 (。・∀・)。
若想 DIY 卻不知道零件去哪裡買的讀者,可以參考此連結 。( •̀ ω •́ )✧
既然已經透過 Serial API 取得 Port 存取權限了,再來我們就要來接收並解析資料了。
若每個需要串列通訊資料的地方都要寫一次讀取相關的程式,會導致程式不好維護,所以我們在此將建立一個模組,負責處理串列通訊資料。
此模組的功能需求為:
建立 port-transceiver.js 模組。
使用觀察者模式,讓物件可以發出事件。
繼承 EventEmitter2,用法說明詳見文檔。
透過 debounce 處理資料接收。
持續有資料接收時不會發送事件,等到超過指定時間後再發出事件。
佇列排程發送資料。
以免不同來源資料在過短時間內同時送出,讓 MCU 解析命令發生錯誤。
透過 Serial API 讀取、發送資料。
src\script\modules\port-transceiver.js
import EventEmitter2 from 'eventemitter2';
import to from 'safe-await';
import { debounce } from 'lodash-es';
export default class extends EventEmitter2.EventEmitter2 {
  port = null;
  reader = null;
  receiveBuffer = [];
  writer = null;
  writeTimer = null;
  cmdsQueue = []; // 命令佇列
  options = {
    /** 命令發送最小間距(ms) */
    writeInterval: 10,
    /** Reader 完成讀取資料之 debounce 時間
     * 由於 Firmata 採樣頻率(Sampling Interval)預設是 19ms 一次
     * 所以只要設定小於 19ms 數值都行,這裡取個整數,預設為 10ms
     * 
     * [參考文件 : Firmata Sampling Interval](https://github.com/firmata/protocol/blob/master/protocol.md#sampling-interval)
     */
    readEmitDebounce: 10,
  };
  /** debounce 原理與相關資料可以參考以下連結
   * 
   * [Debounce 和 Throttle](https://ithelp.ithome.com.tw/articles/10222749)
   */
  debounce = {
    finishReceive: null,
  };
  constructor(port) {
    super();
    // 檢查是否有 open Method
    if (!this.port?.open) {
      throw new TypeError('無效的 Serial Port 物件');
    }
    this.port = port;
    this.debounce.finishReceive = debounce(() => {
      this.finishReceive();
    }, this.options.readEmitDebounce);
    this.start().catch((err) => {
      // console.error(`[ PortTransceiver start ] err : `, err);
      this.emit('err', err);
    });
  }
  /** 開啟發送佇列並監聽 Port 資料 */
  async start() {
    if (!this?.port?.open) {
      return Promise.reject(new Error('Port 無法開啟'));
    }
    const [err] = await to(this.port.open({ baudRate: 57600 }));
    if (err) {
      return Promise.reject(err);
    }
    this.emit('opened');
    this.startReader();
    this.startWriter();
  }
  /** 關閉 Port */
  stop() {
    this.removeAllListeners();
    clearInterval(this.writeTimer);
    this.reader?.releaseLock?.();
    this.writer?.releaseLock?.();
    this.port?.close?.();
  }
	/** Serial.Reader 開始讀取資料
   * 
   * 參考資料:
   * [W3C](https://wicg.github.io/serial/#readable-attribute)
   * [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#reading_data_from_a_port)
   */
  async startReader() {
    const port = this.port;
    if (port.readable.locked) {
      return;
    }
    try {
      this.reader = port.readable.getReader();
      for (; ;) {
        const { value, done } = await this.reader.read();
        if (done) {
          break;
        }
        // console.log(`[ startReader ] value : `, value);
        this.receiveBuffer.push(...value);
        this.debounce.finishReceive();
      }
    } catch (err) {
      this.stop();
      this.emit('err', err);
    } finally {
      this.reader?.releaseLock();
    }
  }
  /** 完成接收,emit 已接收資料 */
  finishReceive() {
    this.emit('data', this.receiveBuffer);
  }
	/** 取得 Serial.Writer 並開啟發送佇列
   * 
   * 參考資料:
   * [W3C](https://wicg.github.io/serial/#writable-attribute)
   */
  startWriter() {
    this.writeTimer = setInterval(() => {
      if (this.cmdsQueue.length === 0) {
        return;
      }
      this.writer = this.port.writable.getWriter();
      const cmd = this.cmdsQueue.shift();
      this.write(cmd.values);
      // console.log(`write : `, cmd.values);
    }, this.options.writeInterval);
  }
  /** 透過 Serial.Writer 發送資料 */
  async write(data) {
    // console.log(`[ write ] data : `, data);
    await this.writer.write(new Uint8Array(data));
    this.writer.releaseLock();
  }
}
接著在 Vuex 引入 port-transceiver.js 模組,讓使用者選擇 Port 成功後,同時建立 port-transceiver 物件。
src\store\modules\core.store.js
/**
 * 管理 Port 物件、系統主要設定
 */
/**
 * @typedef {import('vuex').Module} Module
 */
import PortTransceiver from '@/script/modules/port-transceiver'
/** @type {Module} */
const self = {
  namespaced: true,
  state: () => ({
    port: null,
    /** @type {PortTransceiver} */
    transceiver: null,
  }),
  mutations: {
    setPort(state, port) {
      state.transceiver?.stop?.();
      state.port = port;
      if (!port) {
        state.transceiver = null;
        return;
      }
      state.transceiver = new PortTransceiver(port);
    },
  },
  actions: {
  },
  modules: {
  },
};
export default self;
在 src\app.vue <script> 引入 Vuex 中的 transceiver 物件,試試看有沒有成功發出資料。
/**
 * @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
 */
import { mapState } from 'vuex';
import DialogSystemSetting from '@/components/dialog-system-setting.vue';
export default {
  name: 'App',
  components: {
    'dialog-system-setting': DialogSystemSetting,
  },
  data() {
    return {};
  },
  computed: {
    ...mapState({
      /** @type {PortTransceiver} */
      portTransceiver: (state) => state.core.transceiver,
    }),
  },
  watch: {
    /** 偵測 portTransceiver 變化,如果為有效物件,則註冊監聽事件
     * @param {PortTransceiver} transceiver
     */
    portTransceiver(transceiver) {
      if (!transceiver) {
        return;
      }
      transceiver.on('data', (data) => {
        console.log(`[ transceiver on data ] data : `, data);
      });
    },
  },
  created() {},
  mounted() {},
  methods: {},
};
選擇 Port 之後,會建立 portTransceiver 物件,並監聽 data 事件,效果如下:

將註冊 transceiver 事件的程式包裝成一個 Method 為 initTransceiver(),並加入 Notify 訊息,提示目前狀態,讓使用體驗好一點。
這裡以「// ...」省略沒有變動的程式碼,減少干擾。
src\app.vue <script>
/**
 * @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
 */
import { mapState } from 'vuex';
import DialogSystemSetting from '@/components/dialog-system-setting.vue';
export default {
  name: 'App',
  // ...
  watch: {
    /** 偵測 portTransceiver 變化,如果為有效物件,則進行初始化
     * @param {PortTransceiver} transceiver
     */
    portTransceiver(transceiver) {
      if (!transceiver) {
        return;
      }
      this.initTransceiver();
    },
  },
	// ...
  methods: {
    initTransceiver() {
      /** @type {PortTransceiver} */
      const portTransceiver = this.portTransceiver;
      /** 提示使用者正在等待 Firmata 回應
       * 產生一個可關閉的 Notify,用於後續處理
       * [Notify API](https://v1.quasar.dev/quasar-plugins/notify#programmatically-closing)
       */
      const dismiss = this.$q.notify({
        type: 'ongoing',
        message: '等待 Board 啟動...',
      });
      portTransceiver.on('data', (data) => {
        dismiss();
        console.log(`[ initTransceiver on data ] data : `, data);
      });
      portTransceiver.on('err', (err) => {
				dismiss();
        // 若發生錯誤,則清空選擇 Port
        this.$store.commit('core/setPort', null);
        // 顯示錯誤訊息
        /** @type {string} */
        const msg = err.toString();
        if (msg.includes('Failed to open serial port')) {
          this.$q.notify({
            type: 'negative',
            message: '開啟 Port 失敗,請確認 Port 沒有被占用',
          });
          return;
        }
        this.$q.notify({
          type: 'negative',
          message: `開啟 Port 發生錯誤:${err}`,
        });
      });
    },
  },
};

仔細比對會發現 console.log 印出來的內容與 D04 分析的內容相同,接下來讓我們進入解析資料環節。
建立 Firmata 模組,用於將接收到的數值解析成對應的資料。
首先新增 firmata responce 的資料集,功能需求為:
key:此回應資料的 key。eventName:此資料對應觸發的 event 名稱。matcher():用來判斷回應資料是否符合。getData():將回應資料轉為 Firmata 資料。src\script\firmata\response-define.js
export default [
  // firmwareName: 韌體名稱與版本
  {
    key: 'firmwareName',
    eventName: 'info',
    /**
     * @param {number[]} res 
     */
    matcher(res) {
      // 回傳 Boolean 表示是否相符
    },
    /**
     * @param {number[]} values 
     */
    getData(values) {
		  // 依照 D04 分析過程設計程式。
      // 取得特徵起點
      const index = values.lastIndexOf(0x79);
      const major = values[index + 1];
      const minor = values[index + 2];
      const nameBytes = values.slice(index + 3, -1);
      /** 在 D04 內容中可以知道 MSB 都是 0
       * 所以去除 0 之後,將剩下的 byte 都轉為字元後合併
       * 最後的結果就會是完整的名稱
       */
      const firmwareName = nameBytes
        .filter((byte) => byte !== 0)
        .map((byte) => String.fromCharCode(byte))
        .join('');
      return {
        ver: `${major}.${minor}`,
        firmwareName
      }
    },
  },
]
所以「matcher() 判斷回應資料是否符合」的部分要怎麼做呢?這裡我們用最簡單直接的辦法,直接將數值矩陣轉為字串後,判斷有沒有含有相符字串。(簡單暴力 ヽ(́◕◞౪◟◕‵)ノ)
大家可以自行實作速度更快的演算法 (ง •̀_•́)ง
建立 src\script\utils\utils.js 集中各類運算功能。
/** 判斷 Array 是否包含另一特徵 Array
 * @param {Number[]} array 
 * @param {Number[]} feature 
 * @return {Number[]}
 */
export function matchFeature(array, feature) {
  const arrayString = array.join();
  const featureString = feature.join();
  return arrayString.includes(featureString);
}
實作 matcher() 內容
src\script\firmata\response-define.js
import { matchFeature } from '@/script/utils/utils';
export default [
  // firmwareName: 韌體名稱與版本
  {
    key: 'firmwareName',
    eventName: 'info',
    /**
     * @param {number[]} res 
     */
    matcher(res) {
			// 回應開頭一定為 F0 79
			const featureBytes = [0xF0, 0x79];
      return matchFeature(res, featureBytes);
    },
    // ...
  },
]
建立 firmata.js 模組並引入 response-define.js。
功能需求:
src\script\firmata\firmata.js
/**
 * @typedef {Object} ResponseParseResult 回應資料解析結果
 * @property {string} key 回應 key
 * @property {string} eventName 事件名稱
 * @property {number[]} oriBytes 原始回應值
 * @property {Object} data 解析完成資料
 */
import responsesDefines from '@/script/firmata/response-define';
export default {
	/** 解析回應資料
   * @param {Number[]} res 接收數值
   */
  parseResponse(res) {
    // 找出符合回應
    const matchResDefines = responsesDefines.filter((define) =>
      define.matcher(res)
    );
    if (matchResDefines.length === 0) {
      return [];
    }
    const results = matchResDefines.map((resDefine) => {
      const data = resDefine.getData(res);
      const { key, eventName } = resDefine;
      /** @type {ResponseParseResult} */
      const result = {
        key,
        eventName,
        oriBytes: res,
        data,
      }
      return result;
    });
    return results;
  },
}
接著在 port-transceiver.js 引入 firmata.js,並修改 finishReceive() Method 內容。
src\script\modules\port-transceiver.js
import EventEmitter2 from 'eventemitter2';
import to from 'safe-await';
import { debounce } from 'lodash-es';
import firmata from '@/script/firmata/firmata';
export default class extends EventEmitter2.EventEmitter2 {
  // ...
   finishReceive() {
   // 解析回應內容
    const results = firmata.parseResponse(this.receiveBuffer);
    if (results.length === 0) {
      this.receiveBuffer.length = 0;
      return;
    }
    // emit 所有解析結果 
    results.forEach(({ key, eventName, data }) => {
      // 若 key 為 firmwareName 表示剛啟動,emit ready 事件
      if (key === 'firmwareName') {
        this.emit('ready', data);
      }
      this.emit(eventName, data);
    });
    this.receiveBuffer.length = 0;
  }
  // ...
}
最後回到 app.vue,我們調整一下剛剛 initTransceiver() 內容。
src\app.vue <script>
/**
 * @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
 */
import { mapState } from 'vuex';
import DialogSystemSetting from '@/components/dialog-system-setting.vue';
export default {
  name: 'App',
  // ...
  methods: {
    initTransceiver() {
      /** @type {PortTransceiver} */
      const portTransceiver = this.portTransceiver;
      /** 提示使用者正在等待 Firmata 回應
       * 產生一個透過程式關閉的 Notify,用於後續處理
       * [Notify API](https://v1.quasar.dev/quasar-plugins/notify#programmatically-closing)
       */
      const dismiss = this.$q.notify({
        type: 'ongoing',
        message: '等待 Board 啟動...',
      });
			// 接收 ready 事件
      portTransceiver.on('ready', (data) => {
        dismiss();
        const ver = data.ver;
        const firmwareName = data.firmwareName;
        this.$q.notify({
          type: 'positive',
          message: `初始化成功,韌體名稱「${firmwareName}」,版本:「${ver}」`,
        });
      });
			// 接收 err 事件
      portTransceiver.on('err', (err) => {
        dismiss();
        // 若發生錯誤,則清空選擇 Port
        this.$store.commit('core/setPort', null);
        // 顯示錯誤訊息
        /** @type {string} */
        const msg = err.toString();
        if (msg.includes('Failed to open serial port')) {
          this.$q.notify({
            type: 'negative',
            message: '開啟 Port 失敗,請確認 Port 沒有被占用',
          });
          return;
        }
        this.$q.notify({
          type: 'negative',
          message: `開啟 Port 發生錯誤:${err}`,
        });
      });
    },
  },
};

至此,我們成功取得 Firmata 回傳之「版本號」與「韌體名稱」了!
接著將 Firmata 取得的資料儲存至 Vuex,讓所有組件都能取得。
建立 src\store\modules\board.store.js
/**
 * 管理 Firmata 版本、Pin 清單等等 MCU 開發版相關資料
 */
/**
 * @typedef {import('vuex').Module} Module
 */
import { merge } from 'lodash-es';
/** @type {Module} */
const self = {
  namespaced: true,
  state: () => ({
    info: {
      ver: null,
      firmwareName: null,
      pins: [],
      analogPinMap: {},
    },
  }),
  mutations: {
    setInfo(state, info) {
      merge(state.info, info);
    },
  },
  actions: {
  },
  modules: {
  },
};
export default self;
並在 Vuex 引入。
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
import core from './modules/core.store';
import board from './modules/board.store';
export default new Vuex.Store({
  state: {
  },
  mutations: {
  },
  actions: {
  },
  modules: {
    core, board
  },
});
回到 app.vue,在 initTransceiver() 增加 on('info') 事件。
src\app.vue <script>
/**
 * @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
 */
import { mapState } from 'vuex';
import DialogSystemSetting from '@/components/dialog-system-setting.vue';
export default {
  name: 'App',
  // ...
  methods: {
    initTransceiver() {
      /** @type {PortTransceiver} */
      const portTransceiver = this.portTransceiver;
      // ...
      portTransceiver.on('ready', (data) => {
        // ...
      });
			// 接收 info 事件
      portTransceiver.on('info', (info) => {
	      // 儲存至 Vuex
        this.$store.commit('board/setInfo', info);
      });
      portTransceiver.on('err', (err) => {
				// ...
      });
    },
  },
};
透過 Vue 之 Chrome 外掛,檢查看看有沒有成功存至 Vuex 中。

成功!( ´ ▽ ` )ノ
好不容易取得資料,當然是要顯示出來才行。◝( •ω• )◟
讓我們前往 app.vue,將「版本號」與「韌體名稱」顯示在畫面右下角。
src\app.vue <template lang="pug">
screen
  .info
    | firmwareName - v0.0
  dialog-system-setting
src\app.vue <style lang="sass">
@import '@/styles/quasar.variables.sass'
.screen
  position: absolute
  width: 100%
  height: 100%
  display: flex
  justify-content: center
  align-items: center
  background: linear-gradient(160deg, rgba($teal-1, 0.2), $blue-grey-1)
  color: $grey
  .info
    position: absolute
    right: 0px
    bottom: 0px
    display: flex
    text-align: right
    padding: 14px
    letter-spacing: 1.5px
    font-size: 14px
    color: $grey
效果如下。
看起來 OK,接著從 Vuex 中取得實際資料,並讓 firmwareName 為空時不顯示。
src\app.vue <template lang="pug">
.screen
  .info(v-if='firmwareName')
    | {{ firmwareName }} - v{{ ver }}
  dialog-system-setting
src\app.vue <script>
/**
 * @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
 */
import { mapState } from 'vuex';
import DialogSystemSetting from '@/components/dialog-system-setting.vue';
export default {
  name: 'App',
  // ...
	computed: {
    ...mapState({
      /** @type {PortTransceiver} */
      portTransceiver: (state) => state.core.transceiver,
      ver: (state) => state.board.info.ver,
      firmwareName: (state) => state.board.info.firmwareName,
    }),
  },
  // ...
};

最後換個看起來科幻一點的字體,看起來比較厲害。ԅ(´∀` ԅ)
字體從 Google Font 尋找
在 global.sass 新增一個字體用 Class
src\styles\global.sass
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&display=swap')
// 引入變數
@import '@/styles/quasar.variables.sass'
.c-row
  display: flex
.c-col
  display: flex
  flex-direction: column
.border-radius-m
  border-radius: $border-radius-m !important
.border-radius-s
  border-radius: $border-radius-s !important
// 滾動條
::-webkit-scrollbar 
  width: 3px
  height: 3px  
::-webkit-scrollbar-track
  padding: 5px
  border-radius: 7.5px
::-webkit-scrollbar-thumb 
  border-radius: 7.5px
.font-orbitron
  font-family: 'Orbitron'
加上替換字體的 Class
src\app.vue <template lang="pug">
.screen
  .info.font-orbitron(v-if='firmwareName')
    | {{ firmwareName }} - v{{ ver }}
  dialog-system-setting
看起來應該有比較酷一點吧... (´・ω・`)

port-transceiver.js 接收 COM Port 資料。以上程式碼已同步至 GitLab,大家可以前往下載: